Repository navigation
Conversation
Adds id-keyed setters, getters and removal to TagMap, TagMap.Entry and spans, so a writer that knows the tag skips the name lookup. Squashed from the review history of #12715. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
span.setTag(0L, "") or a null value reached TagMap.getAndRemove(long), which rejects unknown ids, so clearing threw IllegalArgumentException while every id-keyed setter ignores an unknown id. removeTag(long) now ignores it too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The long-valued setters prechecked with needsIntercept, but when the interceptor declined the tag they discarded the box it was given and stored the primitive. Follow the same precheckIntercept -> setBox shape as the other primitive setters, so TagMap keeps the box. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
peer.port is declared per direction in the registry (#12713), so it has no direction-free id: BaseDecorator, shared by client and server decorators, sets it by name, and the set-by-id tests use an unshared int tag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Set-path routing is a per-language concern, so it lives in a Java overlay next to tag-conventions.yaml rather than in the language-agnostic conventions. The overlay declares the keys that exist only to be routed (resource.name, error, sampling directives, ...) and lists every tag TagInterceptor may route. Each listed tag's id carries the INTERCEPTED bit (bit 1), so a setter called with a constant id can fold the interception test away. Serial numbers become public so TagInterceptor can switch on them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TagInterceptor switches on the tag's serial rather than its name, so a known tag is matched under any of its names and the *_OTEL_NAME cases go away. needsIntercept(long) tests the INTERCEPTED bit first, which folds away for a constant id. split-by-tags entries resolve to serials at construction (each direction for a name declared per direction); only custom tags are still matched by name. A test checks that exactly the tags carrying the bit have a case. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A name setter resolves a known tag's name to its id once and sets it as the id-keyed setters do; only a custom tag is still handled by name. The id-keyed setters no longer look up the name to feed the interceptor, so with a constant id the interception test folds to the INTERCEPTED bit. Entry paths (builder ledger, prototypes, default tags) route by the entry's id and precheck before boxing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The generated nameOf switched over every tag's serial: 491 bytes of bytecode, too big for C2 to inline, so each id-keyed set paid two out-of-line calls into it (the span's unknown-id guard and the entry's name). Read a NAMES_BY_SERIAL array instead; nameOf is now 23 bytes and inlines at every caller. Also adds SetTagBenchmark (span setTag by constant id, non-constant id, name, and custom name, against a bare and a synchronized TagMap store). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This comment has been minimized.
This comment has been minimized.
🟢 Java Benchmark SLOs — All performance SLOs passed
PR vs. master results
Commit: Load and DaCapo benchmarks can be triggered manually in the GitLab pipeline. Results will appear in the Benchmarking Platform UI after completion. |
The table is always allocated and never resized, so the split check -- the only run-time check left on a non-intercepted constant-id set -- is a single load, with no null or length test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The span's unknown-id guard called nameOf; KnownTagCodec.isKnown answers the same question from the serial and a generated SERIAL_LIMIT, so it folds away for a constant id. Also note that serials are not stable across releases. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DDSpanContext.setTag calls interceptTag only for intercepted tags. Kept out of line (over FreqInlineSize, 325 bytes of bytecode), it never brings the profiled handler bodies into setTag's compiled code, which would push setTag past InlineSmallCode and stop callers inlining it -- the inlining a constant id needs to fold its interception test. A test now fails if the switch shrinks below the limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An overlay tag exists only to be routed, so the generator now marks it intercepted without a second listing; intercepted: lists only the tags declared in tag-conventions.yaml that the tracer also routes. Also restore the doc comment and @Suppress that the overlay helper displaced, read the serial limit one way, inline isUnknownTag, and drop an unused default. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3f65031560
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| long tagId = KnownTagCodec.keyOf(tag); | ||
| return tagId != 0 ? tagId : customHash(tag); |
There was a problem hiding this comment.
Preserve lookups for direction-specific tag IDs
When a tag such as peer.port is stored using PEER_PORT_INBOUND_ID or PEER_PORT_OUTBOUND_ID, the entry is bucketed by that ID, but keyOf("peer.port") deliberately returns zero because the shared name has no direction; this method therefore hashes a subsequent name-based lookup as a custom string. As a result, get, containsKey, and remove by the map's exposed string key cannot find the entry (and storing both IDs can produce duplicate peer.port keys), violating the Map<String, Object> contract for the new ID setters. Direction-specific entries need a hash compatible with name-based access, or those IDs must be rejected by TagMap.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Good catch, confirmed. peer.port is the only shared-name tag, and nothing in production sets it by id yet. Rather than special-casing the hot lookup path, 69d7849 encodes a SHARED_NAME flag in the reserved id bit 0. The id-keyed TagMap paths reject those ids, and the span setters ignore them, until name resolution knows the span's direction.
Written by Claude, reviewed by @dougqh
There was a problem hiding this comment.
Directional peer.port ids no longer share a bucket identity with their Datadog name, so name-based reads and clears miss stored values. Mixing id-based and name-based setters can also create duplicate tag keys.
🤖 Bits Code Review · Commit 3f65031
| private Entry(long tagId, byte type, long prim, Object obj) { | ||
| // Resolve the name once: it both validates the id and names the entry. | ||
| super(requireKnownName(tagId)); | ||
| this.tagHash = tagId; |
There was a problem hiding this comment.
Preserve name-based access for directional tag ids
After setting PEER_PORT_INBOUND_ID or PEER_PORT_OUTBOUND_ID, getTag("peer.port") misses the value and clearing by name leaves it intact. The entry hashes the id, whereas name-based operations hash "peer.port" because keyOf returns zero for that shared name. Setting the name afterward can create duplicate tag keys instead of replacing the value. Align the bucket identity across id and name operations while preserving directional metadata, and update tests that currently expect separate entries.
Was this helpful? React 👍 or 👎
🤖 Bits Code Review · Open Bits AI session
There was a problem hiding this comment.
Good catch, confirmed. peer.port is the only shared-name tag, and nothing in production sets it by id yet. Rather than special-casing the hot lookup path, 69d7849 encodes a SHARED_NAME flag in the reserved id bit 0. The id-keyed TagMap paths reject those ids, and the span setters ignore them, until name resolution knows the span's direction.
Written by Claude, reviewed by @dougqh
AlexeyKuznetsov-DD
left a comment
There was a problem hiding this comment.
LGTM
My local codex found and confirmed same issue that GitHub codex highlighted.
Left minor comments about possible improvements.
A tag declared once per direction (peer.port) has two ids but one shared Datadog name, and keyOf resolves that name to neither. An entry keyed by one of those ids hashed by the id while name-based access hashed the name as a custom tag, so get/remove/containsKey by name missed it and a later set by name produced a duplicate key. Encode a SHARED_NAME flag in the reserved id bit 0 so the check folds for a constant id. TagMap's id-keyed create/get/remove reject such ids, and DDSpanContext's id-keyed setters ignore them, as they do unknown ids, until name resolution knows the span's direction. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Entry's package-private name factories are now custom-only: they skip the registry and assert the name is not a known tag. Callers that start from a name resolve it once -- TagMap.set(String, ...) forwards a known tag to set(long, ...), while getAndSet, put, Ledger.set, putAll and the public Entry.create(String, ...) API go through anyEntryFor and friends -- so a custom tag pays one registry lookup instead of two. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#12817 rejected shared-name ids (peer.port inbound/outbound) on the id paths until name resolution could see a span's direction. With direction resolution in place, accept them again: a read or removal by the shared name already checks each direction's id. To keep one entry per tag, a write by the shared name stores under whichever direction's tag the map holds (otherwise under the name, which the span re-keys once it has a direction), and a write by id drops a value held under the name alone. isKeyableById goes; the SHARED_NAME bit stays and keeps the new check foldable for a constant id. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts: # .github/CODEOWNERS
TagMap.internals() returns a small Internals view whose setKnown(...) skips the id validation the checked set(long, ...) repeats; DDSpanContext's id-keyed setters, which have already checked isKeyableById, now use it. Internals is created per call, so escape analysis removes it. internals() is marked with a new @restricted(allowedIn = ...) annotation, and gradle/forbiddenApiFilters/instrumentation.txt bans it in instrumentation modules, so calling it there fails forbiddenApisMain. Internals is also where the batched setTagsFrom/TagSink writes will go. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It exists to be created, used within the expression, and scalar-replaced; storing one would force a real allocation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What Does This Do
Moves
TagInterceptorfrom name-keyed to id-keyed dispatch. This is groundwork for instrumentation setting tags byKnownTags.*_IDconstants.tag-conventions-java.yaml, sits next to the language-agnostic conventions. It declares the keys that exist only to be routed (resource.name,error,span.type,manual.keep,sampling.priority, ...) and lists every tagTagInterceptormay route. Each listed tag's id carriesKnownTagCodec.INTERCEPTED(bit 1).TagInterceptorswitches on the tag's serial. A known tag is matched under any of its names, so the*_OTEL_NAMEcases are gone.split-by-tagsentries resolve to a fixed-size table indexed by serial at construction, so the check is a single load; a name declared per direction (peer.port) covers both directions. Only custom tags are still matched by name.DDSpanContextsets by id first. A name setter resolves the id once and then takes the id path. The id setters no longer look up the name to feed the interceptor. Entry paths (builder ledger, prototypes, default tags) route byentry.tagId()and check before boxing.nameOfreads a generatedNAMES_BY_SERIALarray instead of a switch. It was 491 bytes, "hot method too big" for C2; it's now 23 bytes and inlines.Entry's package-private name factories are custom-only: they skip the registry and assert the name isn't a known tag. Callers that start from a name resolve it once.TagMap.set(String, ...)forwards a known tag toset(long, ...);getAndSet,put,Ledger.set,putAlland the publicEntry.create(String, ...)resolve throughanyEntryForand its siblings. A custom tag now pays one registry lookup instead of two:TagMapInsertBenchmark's custom case is 8–12% faster.peer.port) has two ids but one Datadog name, whichkeyOfresolves to neither. Until name resolution knows a span's direction (Resolve direction-dependent tag names by the span's kind (otlp - tag registry - phase 3) #12731), aSHARED_NAMEid bit marks those ids.TagMap's id-keyed create, get and remove reject them, and the span's id setters ignore them (KnownTagCodec.isKeyableById). The check folds away for a constant id.TagMap.internals(): a restricted, trusted path for the tracer core. ItssetKnown(...)skips the id validation thatset(long, ...)repeats. The span's id setters, which have already checkedisKeyableById, use it.Internalsis created per call, so escape analysis removes it. A new@Restricted(allowedIn = ...)annotation marksinternals(), andgradle/forbiddenApiFilters/instrumentation.txtbans it in instrumentation modules (verified: a call from an instrumentation module failsforbiddenApisMain).Internalsis also where the batchedsetTagsFrom/TagSinkwrites will go.Motivation
With a constant id,
setTag(KnownTags.X_ID, v)should inline completely and fold away both the interception test and the custom-tag path. That leaves the store. Once the dense store puts the slot coordinate into the id, the store becomes direct too.C2 inline trees (Zulu 17), steady state:
setTag(HTTP_ROUTE_ID, ...), not intercepted: every call inlines. TheisInterceptedtest folds andinterceptTagdisappears.setTag(SPAN_KIND_ID, ...), intercepted: the same, except for one out-of-line call to theinterceptTagswitch (483 bytes).Before the
nameOfchange, both trees made two out-of-line calls into the 491-bytenameOfswitch on every set.Insert cost (
TagMapInsertBenchmark, 12 tags per op, C2 on Zulu 17, two runs per side), against the commit before the registry (#12354):Setting by id is back to pre-registry speed. By name costs about 3 ns per tag for canonicalization (a
keyOfhit, thennameOf). Custom tags pay akeyOfmiss and 8 bytes per entry; that's a follow-up. The dense store is where ids get ahead of pre-registry.Span
setTag(SetTagBenchmark, C2 on Zulu 17) is about 13.5 ns whichever way the tag is named, against 5.8 ns for a bareTagMapstore. The span's uncontendedsynchronizedis most of the difference, which is why it hides the per-tag gain. Batching (setTagsFromwith one lock per batch) and the dense store are the steps that reach it.Additional Notes
Merge set-by-id (#12715) onto master), with a later merge from master for theCODEOWNERSteam rename (@DataDog/apm-sdk-capabilities). Review this PR's own, non-merge commits:git log --first-parent --no-merges 7a017a5657..dougqh/id-tag-interceptor. I'll rebase once Set known tags by id on TagMap and spans (otlp - tag registry - phase 2) #12715 lands.exactlyTheTagsWithTheInterceptedBitHaveACaseruns every known id through the switch and requires a case exactly when the bit is set. Removingerrorfrom the overlay makes it fail.split-by-tagsfix) is superseded by this PR: id matching covers OTel names. Whichever lands second dropswithCanonicalNames.TagMap's checked id routines throw. The span's guard isKnownTagCodec.isKeyableById: a range check against a generatedSERIAL_LIMIT, plus theSHARED_NAMEbit, both of which fold away for a constant id.interceptTag(long)stays too big for C2 to inline (overFreqInlineSize, 325 bytes of bytecode), on purpose. If it inlined,setTag's standalone compiled code would pick up the profiled handler bodies and grow pastInlineSmallCode(2500 bytes), and callers would stop inliningsetTag.TagInterceptorInliningTestpins the size. A small, foldable handler switch (dense intercepted serials, constant handlers) was tried and works, but it doesn't fit until the dense store shrinkssetTag's store path, so it's deferred to that PR.DDSpan.setTag(long, String)is about 2,300 bytes, against the 2,500-byteInlineSmallCodelimit for inlining an already compiled method into callers. That margin is worth watching as the set path changes.tracerOverlayFile), and overlay tags join no span type's resolved set.🤖 Generated with Claude Code